page.tsx 6.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182
  1. "use client";
  2. import { useEffect, useState } from "react";
  3. import Link from "next/link";
  4. interface OutputFile {
  5. filePath: string;
  6. fileSizeBytes: number;
  7. width: number;
  8. height: number;
  9. durationSeconds: number;
  10. }
  11. interface Job {
  12. id: string;
  13. status: "pending" | "running" | "completed" | "failed";
  14. input: {
  15. text: string;
  16. template: string;
  17. platform: string;
  18. ttsProvider: string;
  19. };
  20. createdAt: number;
  21. updatedAt: number;
  22. error?: string;
  23. outputFiles?: OutputFile[];
  24. }
  25. const STATUS_BADGE: Record<string, string> = {
  26. pending: "badge-pending",
  27. running: "badge-running",
  28. completed: "badge-completed",
  29. failed: "badge-failed",
  30. };
  31. const STATUS_LABEL: Record<string, string> = {
  32. pending: "Waiting",
  33. running: "Rendering...",
  34. completed: "Completed",
  35. failed: "Failed",
  36. };
  37. export default function JobDetailPage({ params }: { params: Promise<{ id: string }> }) {
  38. const [job, setJob] = useState<Job | null>(null);
  39. const [loading, setLoading] = useState(true);
  40. const [id, setId] = useState<string>("");
  41. useEffect(() => {
  42. params.then((p) => setId(p.id));
  43. }, [params]);
  44. useEffect(() => {
  45. if (!id) return;
  46. const load = () => {
  47. fetch(`/api/jobs/${id}`)
  48. .then((r) => r.json())
  49. .then((data: Job) => {
  50. setJob(data);
  51. setLoading(false);
  52. if (data.status === "pending" || data.status === "running") {
  53. setTimeout(load, 2000);
  54. }
  55. })
  56. .catch(() => setLoading(false));
  57. };
  58. load();
  59. }, [id]);
  60. if (loading) {
  61. return (
  62. <div style={{ padding: 48, textAlign: "center", color: "var(--text-muted)" }}>
  63. Loading...
  64. </div>
  65. );
  66. }
  67. if (!job) {
  68. return (
  69. <div style={{ padding: 48, textAlign: "center" }}>
  70. <p>Job not found</p>
  71. <Link href="/jobs"><button className="btn btn-secondary" style={{ marginTop: 16 }}>Back to Jobs</button></Link>
  72. </div>
  73. );
  74. }
  75. return (
  76. <div>
  77. <div className="page-header">
  78. <div style={{ display: "flex", alignItems: "center", gap: 12 }}>
  79. <Link href="/jobs" style={{ color: "var(--text-muted)", fontSize: 14 }}>Jobs</Link>
  80. <span style={{ color: "var(--text-muted)" }}>/</span>
  81. <h1 style={{ fontSize: 22 }}>{job.id.slice(0, 8)}</h1>
  82. </div>
  83. <div style={{ display: "flex", alignItems: "center", gap: 8, marginTop: 8 }}>
  84. <span className={`badge ${STATUS_BADGE[job.status]}`}>
  85. {job.status === "running" && (
  86. <span className="status-dot animate-pulse" style={{ background: "#60a5fa" }} />
  87. )}
  88. {STATUS_LABEL[job.status]}
  89. </span>
  90. <span style={{ fontSize: 13, color: "var(--text-muted)" }}>
  91. {new Date(job.createdAt).toLocaleString("zh-CN")}
  92. </span>
  93. </div>
  94. </div>
  95. {/* Config cards */}
  96. <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 16, marginBottom: 24 }}>
  97. <div className="card">
  98. <div style={{ fontSize: 12, color: "var(--text-muted)", marginBottom: 4 }}>Template</div>
  99. <div style={{ fontWeight: 600 }}>{job.input.template}</div>
  100. </div>
  101. <div className="card">
  102. <div style={{ fontSize: 12, color: "var(--text-muted)", marginBottom: 4 }}>Platform</div>
  103. <div style={{ fontWeight: 600 }}>{job.input.platform}</div>
  104. </div>
  105. <div className="card">
  106. <div style={{ fontSize: 12, color: "var(--text-muted)", marginBottom: 4 }}>TTS Provider</div>
  107. <div style={{ fontWeight: 600 }}>{job.input.ttsProvider}</div>
  108. </div>
  109. <div className="card">
  110. <div style={{ fontSize: 12, color: "var(--text-muted)", marginBottom: 4 }}>Input</div>
  111. <div style={{ fontWeight: 600 }}>JSON ({job.input.text.length} chars)</div>
  112. </div>
  113. </div>
  114. {/* Error */}
  115. {job.error && (
  116. <div className="card" style={{ borderColor: "var(--error)", marginBottom: 24 }}>
  117. <div style={{ color: "var(--error)", fontWeight: 600, marginBottom: 4 }}>Error</div>
  118. <pre style={{ fontSize: 13, whiteSpace: "pre-wrap", color: "var(--error)" }}>{job.error}</pre>
  119. </div>
  120. )}
  121. {/* Output files */}
  122. {job.status === "completed" && job.outputFiles && job.outputFiles.length > 0 && (
  123. <div className="card" style={{ marginBottom: 24 }}>
  124. <h3 style={{ marginBottom: 16 }}>Output</h3>
  125. {job.outputFiles.map((file, i) => (
  126. <div key={i}>
  127. {/* Video player */}
  128. <video
  129. controls
  130. style={{
  131. width: "100%",
  132. maxHeight: 400,
  133. borderRadius: 8,
  134. backgroundColor: "#000",
  135. marginBottom: 12,
  136. }}
  137. src={`/api/download?file=${encodeURIComponent(file.filePath)}`}
  138. />
  139. <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
  140. <div>
  141. <div style={{ fontWeight: 500, marginBottom: 4 }}>{file.filePath.split("/").pop()}</div>
  142. <div style={{ fontSize: 13, color: "var(--text-muted)" }}>
  143. {file.width}x{file.height} | {(file.fileSizeBytes / 1024 / 1024).toFixed(1)}MB
  144. </div>
  145. </div>
  146. <a
  147. href={`/api/download?file=${encodeURIComponent(file.filePath)}&download=1`}
  148. className="btn btn-primary"
  149. style={{ fontSize: 13 }}
  150. >
  151. Download
  152. </a>
  153. </div>
  154. </div>
  155. ))}
  156. </div>
  157. )}
  158. {/* Input text */}
  159. <div className="card">
  160. <div style={{ fontSize: 12, color: "var(--text-muted)", marginBottom: 8 }}>Input Text</div>
  161. <pre style={{ fontSize: 13, color: "var(--text-muted)", whiteSpace: "pre-wrap", maxHeight: 200, overflow: "auto" }}>
  162. {job.input.text.slice(0, 1000)}{job.input.text.length > 1000 ? "..." : ""}
  163. </pre>
  164. </div>
  165. </div>
  166. );
  167. }